Skip to content

Resolve PHP version checks in remaining scope-aware extensions from Scope::getPhpVersion() and forbid injecting PhpVersion into them - #6563

Merged
staabm merged 9 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-dh7ttzc
Sep 23, 2026
Merged

staabm merged 9 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-dh7ttzc

Conversation

@phpstan-bot

@phpstan-bot phpstan-bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Follow-up to #6551. That PR moved most src/Type/Php extensions off the DI-injected PhpVersion. This one moves the last scope-aware extensions to $scope->getPhpVersion() too, so if (PHP_VERSION_ID >= 80000) guards and configured version ranges now affect their return types. It also adds a build rule (as Ondřej suggested in the issue) so new extensions don't go back to injecting PhpVersion.

Changes

  • src/Type/Php/StrSplitFunctionReturnTypeExtension.php: reads the version from the scope. When the analysed versions span PHP 8.2, str_split('') now returns array{}|array{''}. Invalid lengths and encodings return never only when a ValueError is certain.
  • src/Type/Php/MbFunctionsReturnTypeExtension.php, src/Type/Php/MbStrlenFunctionReturnTypeExtension.php: invalid encodings give never or false depending on the scope.
  • src/Type/Php/MbFunctionsReturnTypeExtensionTrait.php: takes PhpVersions as an argument. It caches the full encoding list and filters out PASS/NONE on each call.
  • src/Php/PhpVersion.php, src/Php/PhpVersions.php: new method isZeroValidCodePointInMbSubstituteCharacter(), which replaces the raw getVersionId() < 80000 check.
  • src/Reflection/BetterReflection/Type/AdapterReflectionEnum{,Case}DynamicReturnTypeExtension.php, src/Reflection/PHPStan/NativeReflectionEnumReturnDynamicReturnTypeExtension.php: the >= 8.0 check now uses the scope.
  • build/PHPStan/Build/NoPhpVersionInjectionInScopeAwareExtensionRule.php (registered in build/phpstan.neon): reports a class whose constructor takes PhpVersion if it implements any extension interface that receives a Scope. That covers dynamic return/throw type, type-specifying, parameter closure type/this, parameter-out and expression type resolver extensions.

Looked at and deliberately left alone:

  • BcMathNumber*OperatorTypeSpecifyingExtension: OperatorTypeSpecifyingExtension gets no Scope.
  • ArrayUnpackingHelper: an engine helper used by AssignHandler.
  • RegexArrayShapeMatcher / RegexGroupParser: no Scope is available where the version is checked.

Root cause

These extensions asked a single PhpVersion from DI. That object can't see narrowing from PHP_VERSION_ID conditions or a configured version range, so they could return the wrong type, or a type that is too precise, for the analysed code.

Test

  • tests/PHPStan/Analyser/nsrt/bug-15287.php checks str_split, mb_str_split, mb_strlen, mb_ord, mb_substitute_character under PHP_VERSION_ID branches. It fails before the change and passes after.
  • tests/PHPStan/Analyser/data/scope-php-version-range-return-type-extensions.php adds the same functions under a 7.4–8.5 range, where results must be the union of all versions (e.g. bool for mb_substitute_character(0), array{}|array{''} for str_split('')).
  • tests/PHPStan/Build/NoPhpVersionInjectionInScopeAwareExtensionRuleTest.php covers the new build rule.

Fixes phpstan/phpstan#15287

🤖 Generated with Claude Code

…Scope::getPhpVersion()` and forbid injecting `PhpVersion` into them

- StrSplitFunctionReturnTypeExtension: str_split('') yields array{}|array{''} when the analysed versions straddle 8.2; ValueError/false and empty-array handling use TrinaryLogic from the scope
- MbFunctionsReturnTypeExtension, MbStrlenFunctionReturnTypeExtension: invalid-encoding never/false decided per scope
- MbFunctionsReturnTypeExtensionTrait: caches the full encoding list, filters PASS/NONE per call from the scope's PHP version
- MbSubstituteCharacterDynamicReturnTypeExtension: computes code points valid on all vs. on some analysed versions, so version ranges produce bool instead of a wrong constant; new PhpVersion(s)::isZeroValidCodePointInMbSubstituteCharacter()
- PDOConnectReturnTypeExtension: hasPDOSubclasses() checked against the scope
- AdapterReflectionEnum(Case)DynamicReturnTypeExtension, NativeReflectionEnumReturnDynamicReturnTypeExtension: >= 8.0 check against the scope
- New build rule NoPhpVersionInjectionInScopeAwareExtensionRule reports scope-aware extensions (return type, throw type, type-specifying, closure type/this, param-out, expression type resolver) whose constructor takes PhpVersion
- Not converted: BcMath operator type-specifying extensions (OperatorTypeSpecifyingExtension gets no Scope), ArrayUnpackingHelper (engine helper), RegexArrayShapeMatcher/RegexGroupParser (no Scope at the check site)
public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type
{
if ($this->phpVersion->getVersionId() >= 80000) {
if (IntegerRangeType::fromInterval(80000, null)->isSuperTypeOf($scope->getPhpVersion()->getType())->yes()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this needs a new PhpVersions method instead

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made both requested changes in two separate commits and pushed them. make phpstan reports no errors and make tests passes (22221 tests, 105 skipped).

1. Reflection enum extensions (cd0784380)

  • I added PhpVersions::hasCorrectReflectionEnumAdapterReturnTypes(), which is true on PHP 8.0+.
  • AdapterReflectionEnumDynamicReturnTypeExtension, AdapterReflectionEnumCaseDynamicReturnTypeExtension and NativeReflectionEnumReturnDynamicReturnTypeExtension now call $scope->getPhpVersion()->hasCorrectReflectionEnumAdapterReturnTypes()->yes() instead of building an IntegerRangeType inline.

2. PDO::connect (71a3320e5)

  • PDOConnectReturnTypeExtension is back to exactly how it was before this PR: it injects PhpVersion and checks hasPDOSubclasses() in isStaticMethodSupported().
  • The new build rule would flag that constructor, so I made the rule's error ignorable. The exception is marked on the constructor parameter with // @phpstan-ignore phpstan.phpVersionInjection (PDO subclasses only exist in the runtime when running on PHP 8.4+). A side effect is that anyone else can now silence this rule with an inline ignore too. If you'd rather keep it non-ignorable, the alternative is a list of allowed classes inside the rule.
  • I removed the PDO::connect case from nsrt/bug-15287.php, because it no longer depends on the scope. nsrt/pdo-connect-php84.php still tests this extension.

public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type
{
if ($this->phpVersion->getVersionId() >= 80000) {
if (IntegerRangeType::fromInterval(80000, null)->isSuperTypeOf($scope->getPhpVersion()->getType())->yes()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this needs a new PhpVersions method instead

public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type
{
if ($this->phpVersion->getVersionId() >= 80000) {
if (IntegerRangeType::fromInterval(80000, null)->isSuperTypeOf($scope->getPhpVersion()->getType())->yes()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this needs a new PhpVersions method instead

return null;
}

$valueType = $scope->getType($methodCall->getArgs()[0]->value);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the below listed PDO subclasses only exist in PHP 8.4+ - see https://wiki.php.net/rfc/pdo_driver_specific_subclasses

thats why PDOConnectReturnTypeExtension depends on a PHP 8.4+ runtime and we need to revert to using PhpVersion instead of PhpVersions

phpstan-bot and others added 2 commits September 23, 2026 12:26
…he reflection enum return type extensions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PDO driver subclasses only exist in the runtime on PHP 8.4+, so the
extension depends on the runtime version rather than the scope's one.
The build rule is now ignorable so this exception can be marked inline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
return [];
}

$constructor = $node->getOriginalNode()->getMethod('__construct');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use ClassReflection->has/getConstructor instead

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made both of your changes to NoPhpVersionInjectionInScopeAwareExtensionRule and pushed them as one commit (f69c8bae7), since they rewrite the same lines. make phpstan reports no errors and make tests passes (22221 tests, 105 skipped).

  • Constructor lookup: the rule now uses $classReflection->hasConstructor() and getConstructor()->getOnlyVariant()->getParameters(). It no longer reads __construct from the AST.
  • Type check: each parameter is checked with (new ObjectType(PhpVersion::class))->isSuperTypeOf(TypeCombinator::removeNull($parameter->getType()))->yes(). This replaces the Name/NullableType checks on the AST, and still catches ?PhpVersion.
  • Error line: reflection has no line numbers for parameters, so the error is now reported on the class line instead of the parameter line. To still point at the offending parameter, the message now names it ("…via constructor parameter $phpVersion…").
  • PDO exception: because of the line change, I moved the // @phpstan-ignore phpstan.phpVersionInjection (...) comment in PDOConnectReturnTypeExtension from the constructor parameter to just above #[AutowiredService]. make phpstan still passes, so the ignore still matches.
  • Test: I updated the expected message and line in NoPhpVersionInjectionInScopeAwareExtensionRuleTest.

if ($type instanceof NullableType) {
$type = $type->type;
}
if (!$type instanceof Name || $type->toString() !== PhpVersion::class) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use ObjectType(PhpVersion::class) and isSuperTypeOf instead

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both of the latest review comments on NoPhpVersionInjectionInScopeAwareExtensionRule are addressed and pushed. make phpstan reports no errors and make tests passes (22221 tests, 105 skipped).

My first push was rejected: someone had already pushed f69c8bae7 to the branch while I was working, and it handles both comments the same way my commit did. It:

  • gets the constructor with ClassReflection::hasConstructor() / getConstructor() instead of reading the AST;
  • checks each parameter with (new ObjectType(PhpVersion::class))->isSuperTypeOf(TypeCombinator::removeNull($parameter->getType()))->yes() instead of matching Name/NullableType nodes. removeNull means a ?PhpVersion parameter is caught too.

So I dropped my copy of the same change and kept that commit.

Side effects of f69c8bae7:

  • Reflection parameters have no line numbers, so the error is now reported on the class line rather than the parameter line.
  • The message now names the parameter ("…via constructor parameter $phpVersion…").
  • The @phpstan-ignore in PDOConnectReturnTypeExtension moved up to the class. It still matches: there is no unmatched-ignore error.

What I added (7186c8f6c): a test case for an extension whose constructor takes ?PhpVersion. The test data only had a nullable parameter in a class that isn't an extension, so that path wasn't covered before.

Comment thread src/Type/Php/MbFunctionsReturnTypeExtensionTrait.php
$ranges = [];
$phpVersion = $scope->getPhpVersion();

if ($this->phpVersion->supportsAllUnicodeScalarCodePointsInMbSubstituteCharacter()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reduce changes in this file to a minimum to only introduce PhpVersions - no other drive-by improvements

{

public function __construct(
// @phpstan-ignore phpstan.phpVersionInjection (PDO subclasses only exist in the runtime when running on PHP 8.4+)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

put the ignore into the baseline instead

phpstan-bot and others added 4 commits September 23, 2026 12:45
…ensionRule` via reflection and `ObjectType::isSuperTypeOf()`

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ctionInScopeAwareExtensionRule`

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ionsReturnTypeExtensionTrait` changes to only introduce `PhpVersions`

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e into the baseline

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@SanderMuller SanderMuller left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 32c22190e. The ->yes() gate on the reflection extensions fixes the phpstan-strict-rules regression from #6529. One CI red is this PR's, and the description no longer matches the code in three places.

Generate baseline

This job is red here and green on #6551 and #6485. The regenerated baseline has the same PDOConnectReturnTypeExtension entry as the committed one, but in a different position, so the hand-inserted entry is out of order. Running make phpstan-generate-baseline should fix it.

The description

  • It says PDOConnectReturnTypeExtension now checks hasPDOSubclasses() against the Scope. 71a3320e5 kept the injected PhpVersion, and 32c22190e baselined it.
  • It says MbSubstituteCharacterDynamicReturnTypeExtension builds two code-point types, and it gives bool for mb_substitute_character(0) under a range as the example. 30f79b07c reduced that change to the minimum, as you asked. Under the 7.4 to 8.5 range this head infers false for mb_substitute_character(0), the same as the base, so the example no longer holds.
  • It says bug-15287.php checks PDO::connect. The file has no PDO case.

A note on the minimal trait change

MbFunctionsReturnTypeExtensionTrait keeps PASS and NONE only when the range is certainly 7.2 or older. A range that reaches 7.2 therefore treats them as unsupported, although they work there. Ranges that low are rare, and ->no() would keep them, if you want the wider answer.

What I checked

  • I ran phpstan-strict-rules 2.1.x's own make phpstan with this PR's CI phar (run 35863204809). It gives 0 errors under its ^7.4 || ^8.0 range, where #6529's phar gave 10. It also gives 0 at a fixed 7.4.6 and at a fixed 8.0.
  • With src/ reset to the merge base c230eb0b7, the range test fails 4 of 16, and bug-15287.php fails 19 assertions.
  • I dumped 17 expressions at fixed 7.2, 7.4 and 8.5 and at two ranges, on base and PR. At a fixed version they all agree. Under a range, str_split('') widens to array{}|array{''} and mb_substitute_character(null) to bool, and nothing narrows.
  • Self-analysis reports the same error set as the merge base. phpcs is clean, the Type/Php, Rules/Functions and Build suites pass, and no touched class is turbo-shadowed.

Performance

I counted MutatingScope::getPhpVersion() calls in a single-process self-analysis: 220,786 on the merge base and 221,013 here, so 227 more, or 0.1%.

CI

Apart from Generate baseline, every red also shows on #6485 or #6551. phpstan-nette (8.6) make phpstan fails in composer install on all three.

phpstan-bot and others added 2 commits September 23, 2026 13:37
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…may support them

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

I've pushed two commits that address the review. make tests passes (22219 tests, 105 skipped) and make phpstan reports no errors.

1. Generate baseline (6c662cb52)
I ran make phpstan-generate-baseline. The only change is that the PDOConnectReturnTypeExtension entry I had inserted by hand moves after the MinMaxFunctionReturnTypeExtension entries, which is where the generator puts it. The content of the entry is the same.

2. PASS/NONE encodings (ec89b7d5a)
In MbFunctionsReturnTypeExtensionTrait, I changed !supportsPassNoneEncodings()->yes() to supportsPassNoneEncodings()->no(). The two encodings are now removed only when every analysed version certainly lacks them. A range that reaches 7.2 keeps them as valid, so we no longer claim false or never for an encoding that may work.

3. Description
I didn't edit the PR description. Here are corrected versions of the three items that no longer match the code:

  • PDOConnectReturnTypeExtension: unchanged from the base. It still injects PhpVersion, because the PDO subclasses exist only when PHPStan itself runs on PHP 8.4+. The build rule's error for it is recorded in phpstan-baseline.neon.
  • MbSubstituteCharacterDynamicReturnTypeExtension: each $this->phpVersion->x() call was swapped for $scope->getPhpVersion()->x()->yes() (or ->no()), and the raw getVersionId() < 80000 check now uses the new isZeroValidCodePointInMbSubstituteCharacter(). There's no longer separate handling for version ranges. Under a 7.4–8.5 range, mb_substitute_character(0) still gives false, the same as the base. Exact range handling could be a follow-up PR.
  • Tests: bug-15287.php covers str_split, mb_str_split, mb_strlen, mb_ord and mb_substitute_character, but not PDO::connect. The PDO::connect extension is still tested by nsrt/pdo-connect-php84.php.

@staabm
staabm merged commit c20a20c into phpstan:2.3.x Sep 23, 2026
858 of 881 checks passed
@staabm
staabm deleted the create-pull-request/patch-dh7ttzc branch September 23, 2026 14:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Utilize $scope->getPhpVersion in return-type extensions

3 participants